들어가며

지난 글에서는 암묵적 입출력을 걷어내 계산을 만들고, 그 계산들을 계층으로 쌓는 이야기를 했습니다. 이번에는 한 단계 더 올라갑니다.

  • 일급 추상 — 함수 이름에 숨어 있는 것을 값으로 꺼내기
  • 함수형 도구map, filter, reduce가 왜 함수형의 대표 예시인가
  • CQRS와 이벤트 소싱 — 지금까지의 이야기가 아키텍처로 확장되는 지점

일급 추상

함수 이름에 인자가 숨어 있다

상품 정보를 다루는 함수들을 만들다 보면 이런 코드가 쌓입니다.

function setPrice(product, value) {
  return { ...product, price: value }
}

function setDiscount(product, value) {
  return { ...product, discount: value }
}

function setStock(product, value) {
  return { ...product, stock: value }
}

세 함수는 거의 같습니다. 다른 건 함수 이름에 들어 있는 단어 하나뿐입니다. setPricePrice, setDiscountDiscount. 이걸 암묵적 인자라고 부릅니다. 분명히 인자처럼 동작하는데 인자 자리에 있지 않고 이름에 박혀 있는 것입니다.

이걸 밖으로 꺼내면 이렇게 됩니다.

function setField(product, field, value) {
  return { ...product, [field]: value }
}

setField(product, 'price', 1000)
setField(product, 'discount', 0.1)

이 리팩터링을 암묵적 인자를 드러내기라고 합니다. 절차는 이렇습니다.

  1. 함수 이름에서 암묵적 인자를 찾는다
  2. 그 자리에 명시적인 인자를 추가한다
  3. 본문에 하드코딩되어 있던 값을 인자로 바꾼다
  4. 호출하는 쪽을 고친다

여기서 얻는 건 코드가 줄었다는 것만이 아닙니다. 추상화 단계가 한 칸 올라갔다는 게 핵심입니다. setPrice는 “가격을 바꾼다”는 한 가지 일만 말하지만, setField는 “필드를 바꾼다”는 더 넓은 이야기를 합니다.

함수 이름이 지나치게 특정한 하나만 가리키고 있다면, 거기 인자가 숨어 있을 가능성이 높습니다.

일급값이란

일급값은 언어의 다른 값들처럼 다룰 수 있는 값입니다. 구체적으로는 이런 것들이 가능해야 합니다.

  • 변수에 담을 수 있다
  • 함수의 인자로 넘길 수 있다
  • 함수의 리턴값이 될 수 있다
  • 배열이나 객체에 넣을 수 있다

위에서 'price'라는 문자열은 일급값입니다. 그래서 인자로 넘길 수도 있고 배열에 담을 수도 있습니다. setPrice라는 이름 안에 있던 Price는 일급값이 아니었습니다. 그저 식별자의 일부였을 뿐이라 아무것도 할 수 없었습니다.

일급값으로 만든다는 건 “다룰 수 있게 만든다”는 뜻입니다.

// 일급값이 되면 이런 게 가능해집니다
const fields = ['price', 'discount', 'stock']

const cleared = fields.reduce((product, field) => setField(product, field, 0), product)

자바스크립트에서 일급이 아닌 것들

자바스크립트에서 함수는 일급이지만, 일급이 아닌 것들도 있습니다.

  • 연산자+, -, >
  • 반복문for, while
  • 조건문if, switch
  • try/catch 블록

이것들은 변수에 담을 수 없습니다.

const op = + // 문법 에러입니다

하지만 함수로 감싸면 일급이 됩니다.

const plus = (a, b) => a + b
const isPositive = n => n > 0

;[1, 2, 3].reduce(plus) // 6
;[1, -2, 3].filter(isPositive) // [1, 3]

이게 함수형 프로그래밍이 반복적으로 하는 일입니다. 일급이 아닌 것을 함수로 감싸 일급으로 만들고, 그때부터 값처럼 조립합니다. 뒤에 나올 map, filter도 결국 반복문을 일급으로 만든 결과입니다.

연관 질문

Q. 문자열을 인자로 넘기면 오타에 취약해지지 않나요?

맞습니다. 이게 암묵적 인자를 드러내기의 대가입니다. setPrice(product, 1000)은 오타가 나면 함수를 못 찾지만, setField(product, 'pirce', 1000)은 조용히 이상한 필드를 만들어냅니다.

책에서는 이 문제를 “인자를 문서화하고, 값을 제한하고, 정적 타입 시스템을 쓰라”고 정리하는데, 타입스크립트에서는 세 번째가 거의 공짜로 해결됩니다.

type Product = { price: number; discount: number; stock: number }

// field에 아무 문자열이나 들어올 수 있습니다
function setField(product: Product, field: string, value: unknown) {}

// keyof로 실제 존재하는 키만 허용합니다
function setField<K extends keyof Product>(
  product: Product,
  field: K,
  value: Product[K],
): Product {
  return { ...product, [field]: value }
}

setField(product, 'price', 1000) // OK
setField(product, 'pirce', 1000) // 에러: 'pirce'는 Product의 키가 아닙니다
setField(product, 'price', '1000') // 에러: price는 number입니다

여기서 value: Product[K]가 중요합니다. 키뿐 아니라 그 키에 들어갈 값의 타입까지 같이 좁혀지기 때문입니다. 일급값으로 만들면서 잃었던 안전성을 제네릭이 돌려주는 셈입니다.

실제로 비슷하게 쓴 코드가 있었습니다.

paginationEvent<K extends keyof this>(key: K, value: unknown) {
  this[key] = value as this[K]
  this.setChartList()
}

방향은 같은데 두 군데가 아쉽습니다.

첫째, value: unknown으로 받고 as this[K]로 캐스팅하면 값 타입 검사가 사라집니다. 처음부터 value: this[K]로 받으면 캐스팅 자체가 필요 없습니다.

둘째, keyof this메서드까지 포함합니다. paginationEvent('setChartList', ...)가 타입 검사를 통과해버립니다. 실제로 바꿀 필드만 union으로 좁히는 편이 안전합니다.

type PaginationKey = 'pageIndex' | 'pageSize' | 'sortKey'

paginationEvent<K extends PaginationKey>(key: K, value: this[K]) {
  this[key] = value
  this.setChartList()
}

지난 글의 작은 인터페이스 이야기와 같은 맥락입니다. 열어둘 수 있다고 다 열어둘 필요는 없습니다.


본문을 콜백으로 바꾸기

암묵적 인자 드러내기가 을 꺼내는 리팩터링이라면, 이건 동작을 꺼내는 리팩터링입니다.

이런 코드가 반복된다고 해보겠습니다.

try {
  saveUserData(user)
} catch (error) {
  logToErrorService(error)
}

try {
  fetchProduct(id)
} catch (error) {
  logToErrorService(error)
}

앞부분(try {)과 뒷부분(} catch ...)은 똑같고, 가운데 본문만 다릅니다. 그러면 본문을 콜백으로 빼냅니다.

function withErrorLogging(fn) {
  try {
    fn()
  } catch (error) {
    logToErrorService(error)
  }
}

withErrorLogging(() => saveUserData(user))
withErrorLogging(() => fetchProduct(id))

여기서 한 가지 주의할 게 있습니다.

withErrorLogging(saveUserData(user)) // 잘못된 사용입니다

이렇게 쓰면 saveUserData(user)먼저 실행되고 그 결과가 인자로 넘어갑니다. try 안으로 들어가는 게 아니라 밖에서 터집니다. 콜백으로 감싸는 이유는 동작 자체를 넘기기 위해서가 아니라, 실행 시점을 넘기기 위해서입니다.

고차 함수

withErrorLogging처럼 함수를 인자로 받거나 함수를 리턴하는 함수를 고차 함수라고 합니다. 함수가 일급값이기 때문에 가능한 일입니다.

map, filter, reduce, debounce, 리액트의 HOC, 앵귤러의 인터셉터까지 전부 고차 함수입니다. 우리가 이미 매일 쓰고 있는 개념이라는 게, 사실 이 챕터를 읽으면서 가장 확실해진 부분이었습니다.

연관 질문

Q. 함수만이 아니라 컴포넌트도 합성 컴포넌트 패턴으로 만들었어야 하지 않았을까?

이 질문을 스스로 하게 됐는데, 지금 정리해보니 합성 컴포넌트 패턴은 “본문을 콜백으로 바꾸기”의 컴포넌트 버전에 가깝습니다.

<!-- prop 이름에 암묵적 인자가 잔뜩 들어 있습니다 -->
<app-modal
  [showHeader]="true"
  [headerTitle]="'삭제하시겠습니까?'"
  [showFooter]="true"
  [confirmText]="'삭제'"
  [cancelText]="'취소'"
/>

showHeader, headerTitle, confirmText… 요구사항이 하나 늘 때마다 prop이 하나씩 늘고, 컴포넌트 안의 @if도 같이 늘어납니다. setPrice, setDiscount, setStock이 늘어나던 것과 정확히 같은 냄새입니다.

<!-- 안에 무엇을 그릴지를 넘깁니다 -->
<app-modal>
  <app-modal-header>삭제하시겠습니까?</app-modal-header>
  <app-modal-body>이 작업은 되돌릴 수 없습니다.</app-modal-body>
  <app-modal-footer>
    <button (click)="cancel()">취소</button>
    <button (click)="confirm()">삭제</button>
  </app-modal-footer>
</app-modal>

함수에서는 실행할 코드를 콜백으로 넘겼고, 컴포넌트에서는 그릴 조각을 슬롯으로 넘깁니다. 넘기는 게 동작이냐 마크업이냐만 다를 뿐, “가변적인 부분을 밖에서 주입한다”는 구조는 같습니다.

바꿔 말하면 모달이라는 컴포넌트는 껍데기와 레이아웃만 책임지고, 안에 무엇이 들어갈지는 모른 채로 남아 있게 됩니다. 이게 지난 글의 추상화 벽과도 이어집니다.

물론 모든 아토믹 컴포넌트를 합성으로 만들 필요는 없습니다. prop이 두세 개로 끝나는 버튼까지 슬롯으로 쪼개면 쓰는 쪽만 번거로워집니다. “이 컴포넌트의 prop이 표시 여부를 제어하는 불리언으로 채워지기 시작할 때” 가 신호라고 생각합니다.


함수형 도구

map, filter, reduce가 왜 대표 예시인가

전에는 map, filter, reduce가 함수형 프로그래밍의 대표 예시로 나오는 게 잘 이해되지 않았습니다. 그냥 배열 메서드인데 왜 이 이야기의 주인공이 될까 싶었습니다.

앞의 두 리팩터링을 거치고 나니 명확해졌습니다. 이 셋은 반복문의 본문을 콜백으로 바꾼 결과물입니다.

// for문은 언제나 이 구조입니다
const result = []
for (const item of items) {
  result.push(transform(item)) // 이 한 줄만 매번 다릅니다
}
return result

// 그 한 줄을 콜백으로 빼내면 map이 됩니다
return items.map(transform)

filter도, reduce도 마찬가지입니다. 앞뒤가 똑같고 가운데만 다르니 가운데를 인자로 받은 것입니다. 표준 라이브러리에 이미 들어 있는 “본문을 콜백으로 바꾸기”의 결과물인 셈입니다.

세 도구는 이렇게 정리됩니다.

도구 하는 일 결과
map 각 항목을 변환 길이는 그대로, 값이 바뀜
filter 각 항목을 선택 값은 그대로, 개수가 줄어듦
reduce 전체를 하나로 합침 배열이 아닌 것이 될 수도 있음

reduce가 가장 강력한데, mapfilter 둘 다 reduce로 만들 수 있기 때문입니다. “목록을 순회하며 값을 누적한다” 는 게 반복문이 하는 일의 거의 전부이기도 합니다.

체인을 만드는 세 단계

여러 도구를 이어 붙일 때는 이 순서로 접근합니다.

  1. 단계에 맞는 데이터 만들기 — 각 단계가 다루기 좋은 형태로 데이터를 준비한다
  2. 배열 전체를 다루기 — 항목 하나가 아니라 배열 전체를 한 번에 생각한다
  3. 작은 단계로 나누기 — 한 단계가 너무 많은 일을 하면 더 쪼갠다

“구매를 3번 이상 한 우수 고객들의, 각자 가장 큰 구매 건을 찾는다”는 요구사항으로 보겠습니다.

// 절차적 코드
function biggestPurchasesBestCustomers(customers) {
  const result = []

  for (const customer of customers) {
    if (customer.purchases.length >= 3) {
      let biggest = { total: 0 }

      for (const purchase of customer.purchases) {
        if (purchase.total > biggest.total) {
          biggest = purchase
        }
      }

      result.push(biggest)
    }
  }

  return result
}
// 체이닝
function biggestPurchasesBestCustomers(customers) {
  return customers
    .filter(customer => customer.purchases.length >= 3)
    .map(customer => maxBy(customer.purchases, purchase => purchase.total))
}

절차적 코드와의 차이

두 코드는 같은 일을 합니다. 그런데 읽을 때 머릿속에서 하는 일이 다릅니다.

절차적 코드는 “어떻게” 를 말합니다. 빈 배열을 만들고, 하나씩 돌면서, 조건을 확인하고, 임시 변수를 갱신하고, 결과에 넣습니다. 읽는 사람은 resultbiggest가 지금 어떤 상태인지를 머릿속에서 계속 추적해야 합니다.

체이닝은 “무엇을” 을 말합니다. 우수 고객을 고르고, 각자의 최대 구매를 찾습니다. 중간 변수가 없으니 추적할 상태도 없습니다. 각 단계가 이름을 가지고 있고, 각 단계를 따로 떼어 테스트하거나 재사용할 수 있습니다.

물론 공짜는 아닙니다. 단계마다 중간 배열이 새로 생기기 때문에, 수십만 건을 다루는 경로에서는 한 번의 reduce로 합치거나 지연 평가를 고려해야 합니다. 다만 대부분의 화면 코드에서 이 비용은 측정되지 않을 만큼 작고, 읽기 쉬워지는 이득이 훨씬 큽니다. 먼저 체이닝으로 쓰고, 문제가 측정됐을 때 되돌리는 순서가 맞다고 생각합니다.


한 걸음 더: CQRS와 이벤트 소싱

이번 챕터를 정리하다 보니 아키텍처 쪽 개념 두 가지가 계속 겹쳐 보였습니다.

CQRS

Command Query Responsibility Segregation. 상태를 바꾸는 명령(Command)과 상태를 읽는 조회(Query)의 책임을 분리하는 패턴입니다.

지난 글의 “읽기와 쓰기를 섞지 말고 분리하자” 가 함수 단위의 이야기였다면, CQRS는 그걸 시스템 단위로 올린 것입니다. 쓰기 모델과 읽기 모델을 아예 다른 모델로 두고, 심지어 저장소까지 분리하기도 합니다.

pop()getLast()dropLast()로 나누던 것과 방향이 정확히 같습니다. 규모만 다를 뿐입니다.

이벤트 소싱

현재 상태를 저장하는 대신, 일어난 사건의 목록을 저장하는 방식입니다. 현재 상태는 그 목록을 처음부터 접어서 만들어냅니다.

const currentState = events.reduce(applyEvent, initialState)

이게 정확히 reduce입니다. 그리고 1편에서 정리했던 “데이터는 이벤트에 대해 기록한 사실” 이라는 정의가 그대로 저장 전략이 된 형태이기도 합니다.

  • 잔액 500원을 저장한다 → 상태를 저장
  • 1000원 입금, 700원 출금, 200원 입금을 저장한다 → 이벤트를 저장

두 번째 방식에서는 잔액이 어떻게 500원이 됐는지 전부 남아 있고, 중간 시점의 상태도 되살릴 수 있습니다. 대신 조회할 때마다 접어야 하니 읽기가 비싸지고, 그래서 보통 CQRS와 함께 쓰입니다. 쓰기는 이벤트로 쌓고, 읽기용 모델은 따로 만들어두는 식입니다.

리덕스도 정확히 같은 구조입니다. 액션이라는 이벤트를 흘려보내고, 리듀서로 접어서 현재 상태를 만듭니다. 우리가 reduce라고 부르는 그 함수 이름이 reducer인 것도 우연이 아닙니다.


정리

이번 글의 흐름은 이렇게 이어집니다.

  1. 함수 이름에 숨어 있던 을 인자로 꺼낸다 → 암묵적 인자를 드러내기
  2. 함수 안에 박혀 있던 동작을 인자로 꺼낸다 → 본문을 콜백으로 바꾸기
  3. 그 결과 만들어진 표준 도구를 조합한다 → map, filter, reduce

결국 계속 같은 일을 하고 있습니다. 변하는 부분을 값으로 꺼내서 밖에서 주입할 수 있게 만드는 것. 값을 꺼내면 인자가 되고, 동작을 꺼내면 콜백이 되고, 마크업을 꺼내면 합성 컴포넌트가 됩니다.

mapfilter는 원래도 매일 쓰던 메서드였지만, “반복문의 본문을 콜백으로 바꾼 결과”라는 걸 알고 나니 다르게 보입니다. 함수형 프로그래밍이 대단히 새로운 것을 요구하는 게 아니라, 이미 쓰고 있는 것들이 왜 그렇게 생겼는지를 설명해주는 쪽에 가깝다는 생각이 들었습니다.